// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { FileService, type FileAccessContext } from '@/lib/services/fileService'; import { promises as fs } from 'fs'; import path from 'path'; import archiver from 'archiver'; import db from "@/db/db"; import { fileItems } from "@/db/schema/fileSystem"; import { eq, and } from "drizzle-orm"; interface FileWithPath { file: any; absolutePath: string; relativePath: string; } export async function GET( request: NextRequest, { params }: { params: { projectId: string; folderId: string } } ) { try { const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); } const context: FileAccessContext = { userId: Number(session.user.id), userDomain: session.user.domain || 'partners', userEmail: session.user.email, ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, userAgent: request.headers.get('user-agent') || undefined, }; // 폴더 정보 가져오기 const folder = await db.query.fileItems.findFirst({ where: and( eq(fileItems.id, params.folderId), eq(fileItems.projectId, params.projectId) ), }); if (!folder || folder.type !== 'folder') { return NextResponse.json( { error: '폴더를 찾을 수 없습니다' }, { status: 404 } ); } const fileService = new FileService(); const downloadableFiles: FileWithPath[] = []; const unauthorizedFiles: string[] = []; // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인 const processFolder = async ( folderId: string, folderPath: string = '' ): Promise => { const items = await db.query.fileItems.findMany({ where: and( eq(fileItems.parentId, folderId), eq(fileItems.projectId, params.projectId) ), }); for (const item of items) { if (item.type === 'file') { // 파일 권한 확인 const hasAccess = await fileService.checkFileAccess( item.id, context, 'download' ); if (!hasAccess) { // 권한이 없는 파일 기록 unauthorizedFiles.push(path.join(folderPath, item.name)); continue; } if (!item.filePath) continue; // 실제 파일 경로 구성 const nasPath = process.env.NAS_PATH || "/evcp_nas"; const isProduction = process.env.NODE_ENV === "production"; let absolutePath: string; if (isProduction) { const relativePath = item.filePath.replace('/api/files/', ''); absolutePath = path.join(nasPath, relativePath); } else { absolutePath = path.join(process.cwd(), 'public', item.filePath); } // 파일 존재 여부 확인 try { await fs.access(absolutePath); downloadableFiles.push({ file: item, absolutePath, relativePath: path.join(folderPath, item.name) }); // 다운로드 카운트 증가 및 로그 기록 await fileService.downloadFile(item.id, context); } catch (error) { console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`); } } else if (item.type === 'folder') { // 하위 폴더 재귀 처리 await processFolder( item.id, path.join(folderPath, item.name) ); } } }; // 폴더 처리 시작 await processFolder(params.folderId, folder.name); // 권한이 없는 파일이 있으면 다운로드 차단 if (unauthorizedFiles.length > 0) { return NextResponse.json( { error: '일부 파일에 대한 다운로드 권한이 없습니다', unauthorizedFiles: unauthorizedFiles, unauthorizedCount: unauthorizedFiles.length, message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}` }, { status: 403 } ); } // 다운로드할 파일이 없는 경우 if (downloadableFiles.length === 0) { return NextResponse.json( { error: '다운로드 가능한 파일이 없습니다' }, { status: 404 } ); } // 파일 크기 합계 체크 (최대 500MB) const totalSize = downloadableFiles.reduce((sum, item) => sum + (item.file.size || 0), 0 ); const maxSize = 500 * 1024 * 1024; // 500MB if (totalSize > maxSize) { return NextResponse.json( { error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`, totalSize: totalSize, maxSize: maxSize, fileCount: downloadableFiles.length }, { status: 400 } ); } console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); // ZIP 스트림 생성 const archive = archiver('zip', { zlib: { level: 5 } // 압축 레벨 }); // 스트림을 Response로 변환 const stream = new ReadableStream({ start(controller) { archive.on('data', (chunk) => controller.enqueue(chunk)); archive.on('end', () => controller.close()); archive.on('error', (err) => { console.error('Archive error:', err); controller.error(err); }); }, }); // 파일들을 ZIP에 추가 (폴더 구조 유지) for (const { file, absolutePath, relativePath } of downloadableFiles) { try { const fileBuffer = await fs.readFile(absolutePath); archive.append(fileBuffer, { name: relativePath }); } catch (error) { console.error(`파일 추가 실패: ${relativePath}`, error); } } // ZIP 완료 archive.finalize(); // Response Headers 설정 const headers = new Headers(); headers.set('Content-Type', 'application/zip'); headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`); headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('Expires', '0'); headers.set('X-File-Count', downloadableFiles.length.toString()); headers.set('X-Total-Size', totalSize.toString()); return new NextResponse(stream, { status: 200, headers, }); } catch (error) { console.error('폴더 다운로드 오류:', error); return NextResponse.json( { error: '폴더 다운로드에 실패했습니다' }, { status: 500 } ); } } // 폴더 다운로드 전 권한 체크 (선택적) export async function HEAD( request: NextRequest, { params }: { params: { projectId: string; folderId: string } } ) { try { const session = await getServerSession(authOptions); if (!session?.user) { return new NextResponse(null, { status: 401 }); } const context: FileAccessContext = { userId: Number(session.user.id), userDomain: session.user.domain || 'partners', userEmail: session.user.email, ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, userAgent: request.headers.get('user-agent') || undefined, }; const fileService = new FileService(); let totalFiles = 0; let unauthorizedCount = 0; let totalSize = 0; // 재귀적으로 권한 체크 const checkFolder = async (folderId: string): Promise => { const items = await db.query.fileItems.findMany({ where: and( eq(fileItems.parentId, folderId), eq(fileItems.projectId, params.projectId) ), }); for (const item of items) { if (item.type === 'file') { totalFiles++; totalSize += item.size || 0; const hasAccess = await fileService.checkFileAccess( item.id, context, 'download' ); if (!hasAccess) { unauthorizedCount++; } } else if (item.type === 'folder') { await checkFolder(item.id); } } }; await checkFolder(params.folderId); const headers = new Headers(); headers.set('X-Total-Files', totalFiles.toString()); headers.set('X-Unauthorized-Files', unauthorizedCount.toString()); headers.set('X-Total-Size', totalSize.toString()); headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false'); return new NextResponse(null, { status: unauthorizedCount > 0 ? 403 : 200, headers, }); } catch (error) { console.error('권한 체크 오류:', error); return new NextResponse(null, { status: 500 }); } }